import jsonDiff from "json-diff";

export type JsonLike =
	| string
	| number
	| boolean
	| null
	| JsonLike[]
	| undefined // undefined is not a JSON type but it needs to be included here since it is present in the diff objects
	| { [id: string]: JsonLike };

/**
 * Given two objects A and B that are Json serializable this function computes the difference between them
 *
 * The difference object includes:
 *  - fields in object B but not in object A included as `<fieldKey__added>`
 *  - fields in object A but not in object B included as `<fieldKey__deleted>`
 *  - fields present in both objects but modified as `<fieldKey>: { __old: <objectAValue>, __new: <objectBValue> }`
 *
 * Additionally the difference object contains a `toString` method that can be used to generate a string representation
 * of the difference between the two objects (to be presented to users)
 *
 * @param jsonObjA The first target object
 * @param jsonObjB The second target object
 * @returns An object representing the diff between the two objects, or null if the objects are equal
 */
export function diffJsonObjects(
	jsonObjA: Record<string, JsonLike>,
	jsonObjB: Record<string, JsonLike>
): Record<string, JsonLike> | null {
	const result = jsonDiff.diff(jsonObjA, jsonObjB);

	if (result) {
		result.toString = () => jsonDiff.diffString(jsonObjA, jsonObjB);
		return result;
	} else {
		return null;
	}
}

/**
 * Given a diff object (generated by `diffJsonObjects`) this function computes whether the
 * difference is non-destructive, i.e. if the second object only contained additions to the
 * first one and no removal nor modifications.
 *
 * @param diff The difference object to use (generated by `diffJsonObjects`)
 * @returns `true` if the difference is non-destructive, `false` if it is
 */
export function isNonDestructive(diff: JsonLike): boolean {
	if (diff === null || typeof diff !== "object") {
		return true;
	}

	if (
		Object.keys(diff).some(
			(key) => key === "__old" || key.endsWith("__deleted")
		)
	) {
		return false;
	}

	if (Array.isArray(diff)) {
		for (const element of diff) {
			if (Array.isArray(element) && element.length === 2) {
				if (element[0] === "-") {
					// json-diff shows a removed element by representing it as the following array:
					// ["-", <removed-element>], so if the first value here is "-" we assume that this is
					// a removal
					return false;
				} else if (element[0] === "~") {
					// json-diff shows a modified element by representing it as the following array:
					// ["~", <diff-oject>], so if the first value here is "~" we assume that this is
					// a modification
					return false;
				} else if (element[0] !== "+") {
					// json-diff shows an added element by representing it as the following array:
					// ["+", <added-element>], so if the first value here is "+" we assume that this is
					// an addition (so we skip this)
					continue;
				}

				// Otherwise we check all the elements in the array
				for (const innerElement of element) {
					if (!isNonDestructive(innerElement)) {
						return false;
					}
				}
			} else if (!isNonDestructive(element)) {
				return false;
			}
		}
	} else {
		for (const field in diff) {
			if (!isNonDestructive(diff[field])) {
				return false;
			}
		}
	}

	return true;
}

/**
 * A modified value in json-diff is represented as an object with two properties:
 * `__old` and `__new`. Where the former contains the old version of the value and
 * the latter the new one.
 * This utility, given an arbitrary value, discerns whether the value represents the
 * diff of a modified value.
 *
 * @param value The target value to check
 * @returns True if the value represents a value modified, false otherwise
 */
export function isModifiedDiffValue<T extends JsonLike>(
	value: unknown
): value is { __old: T; __new: T } {
	return !!(
		value &&
		typeof value === "object" &&
		Object.keys(value).length === 2 &&
		"__old" in value &&
		"__new" in value
	);
}
